Skip to content

Resolve native templates per container instead of through mutable statics - #573

Open
lagergren wants to merge 8 commits into
masterfrom
lagergren/master-instance-template-cache
Open

Resolve native templates per container instead of through mutable statics#573
lagergren wants to merge 8 commits into
masterfrom
lagergren/master-instance-template-cache

Conversation

@lagergren

@lagergren lagergren commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

The defect

Native templates published themselves into mutable public static fields from their constructors:

public class xEnum extends xConst {
    public static xEnum INSTANCE;                      // plain mutable static

    public xEnum(Container container, ClassStructure structure, boolean fInstance) {
        super(container, structure, false);
        if (fInstance) {
            INSTANCE = this;                           // this-escape, during construction
        }
    }
}

139 sites did this. 196 sites read the result. None of the fields were volatile or final.

Templates are constructed per container (NativeContainer:172-175), and a JVM can build several — InterpreterConnector:36 does new NativeContainer(runtime, repository), and so does every embedding host and several tests.

It is wrong in series, with no concurrency involved

Every Connector builds its own root: InterpreterConnector's constructor is new Runtime() plus new NativeContainer(...), and Runner builds a Connector per run. A host that runs several modules in one JVM therefore has several native containers alive at once — the Gradle plugin's direct mode does exactly this, in one cached classloader.

All ~139 native templates are constructed by a native container (newInstance(this, structClass, Boolean.TRUE)), so each new root overwrites the statics. Two native containers are unrelated — neither is an ancestor or descendant of the other — so code acting on root A's behalf then reads a template owned by root B: wrong container, wrong pool, wrong type system.

To be precise about what is not the problem: nested containers under a single root never construct native templates, and getTemplate(String) resolves through getNativeContainer(), so under one root INSTANCE holds an ancestor-owned template and reading it is legitimate. This bug needs a second root.

The clearest case is Proxy.makeAsyncNativeHandle, which does new AsyncHandle(INSTANCE.f_container, method): it takes a Container out of a JVM-global mutable static — and with more than one root, that container can belong to an entirely different runtime.

Note that the reachability sweep cannot catch this: it deliberately does not enumerate static roots. That is a reason to delete the static, not to trust a clean sweep.

xEnum.initNative() also branches on if (this == INSTANCE), so which object won the assignment decides whether native rebase initialisation runs at all. That is behaviour, not caching.

Add threads and it is additionally a this-escape publishing a partially-constructed object to a shared static with no safe publication.

The change

Template lookup moves into one container-owned table (NativeTemplates), reached from the container that is asking rather than from a static. The dangerous pattern is expressed once, in one reviewable place, instead of being pasted 139 times.

Zero INSTANCE = this and zero mutable public static … INSTANCE declarations remain. Net −75 lines (187 files, +1639/−1714) despite adding the module.

Seven commits, each building independently: the table; the migration; a note recording why the fInstance constructor flag has to stay (it does not become redundant, contrary to my first assumption); two readability passes (container accessors, and renaming that flag to fBaseTemplate); a dead cast; and the test rework described below.

Verification

  • ./gradlew build green; :javatools:test 351 tests, 0 failures, 40 skips (the standing set).
  • End-to-end xcc + xec on a real module: output byte-identical to master.
  • Commits 1 and 2 each verified to build on their own.

A review pass over my own change caught two regressions I had introduced, both from a scripted rewrite threading a container or frame that master deliberately leaves null: an NPE on xRTType.makeHandle's foreign-handle path (three callers pass null intentionally), and an NPE on every native-throwable translation in xException, because Utils.translate passes a null frame. Both are fixed, folded into the commits that introduced them, and pinned by ownerlessFactoryPathsStillWork.

That pass also found composition caches I had bound to the first caller's container — which could be a short-lived nested container pinned forever in a static — where master bound them to the long-lived native container. Restored.

Readability: say which container is being asked

Two accessors, in their own commit, because the migration otherwise made the diff harder to read than the code it replaced. Call sites had become:

frame.f_context.f_container.nativeTemplates().get(xListMap.class)

Four hops to name one template — and when the lookup is four hops through another object's fields, a reader cannot see whose table is being asked, which is the question this PR exists to make answerable.

  • Frame.container() — three lines; master had no such accessor.
  • Container.nativeTemplate(Class<T>) and Frame.nativeTemplate(Class<T>) — a shorthand for the case that is 91% of all uses.

On the lines this PR introduces: f_context.f_container went 89 → 1, and nativeTemplates().get(...) went 256 → 1. Both survivors are deliberate — one chain starts from a ServiceHandle, which has no container(), and the other is Container.nativeTemplate's own body.

NativeTemplates.of(...) keeps all four overloads; they are layered rather than redundant, and each now says so in its javadoc. of(TypeComposition) alone has 21 call sites, and neither it nor of(ClassTemplate) can be replaced by the shorthand, because those types have no owner accessor of their own. ClassTemplate deliberately did not gain a nativeTemplate(...)isNativeInstance now reads this == f_container.nativeTemplate(clz), which names the owner at the identity comparison instead of hiding it.

The ~198 pre-existing f_context.f_container sites elsewhere in the tree are untouched. That sweep is mechanical and obvious, but it does not belong inside a 181-file diff.

fInstance renamed to fBaseTemplate

The flag used to mean "assign yourself to INSTANCE". That mechanism is gone, so the name was a fossil pointing at a removed mechanism — and with 151 declarations against 6 real consumers, a reader's first conclusion is that it is dead.

It is not. Removing it was attempted, and fails during bootstrap: xService is constructed both as the rebased base template over the Service interface and as the template for every concrete service class, and only the first may build a NativeRebaseConstant, whose constructor asserts its argument is an interface. The table cannot answer the question either, and that is by design — it is populated after a template finishes constructing, which is precisely the property that stops templates escaping mid-construction.

fBaseTemplate rather than fRebase, because five consumers turn it into a rebase but the sixth does not: xRTConnector passes false to its super and uses its own flag to build a process-wide user-agent string. "This is the container's base template for this class" is true at all 151 sites; "rebase" is not.

What this does NOT fix — see #572

This removes the INSTANCE template cache. It does not end last-writer-wins across containers, because a large population of owner-bearing values is still held in mutable public static fields assigned from per-container initNative(): xObject.CLASS, xString.EMPTY_STRING/EMPTY_ARRAY, xBoolean.TRUE/FALSE, xNullable.NULL, xArray's *_ARRAY_CLZ compositions, xRTDelegate.DELEGATES, and others.

Two places that leaks through a public API even after this change:

  • xString.makeHandle(Container, char[]) returns the global EMPTY_STRING for a zero-length array, ignoring the container it was handed;
  • xArray.makeBitArrayHandle and friends derive their owner from a static TypeComposition.

Those are pre-existing and out of scope here, but they mean the guarantee this change provides is narrower than its name suggests, and I would rather say so than let the name carry more weight than the code. Filed as #572.

Native templates are built per container: NativeContainer constructs a
complete set for every instance, and a single JVM can build several. Each
template carries an owner, so a lookup has to answer with the template that
belongs to the container doing the asking.

NativeTemplates is the one place that answers that question. Templates that
are addressable by name in the container's own registry - the name follows
from the class, numbers.xFloat64 backs numbers.Float64 - are resolved lazily
on first use. Templates that implement a composite type declared by another
template, and so have no name of their own, are published by their declaring
template from registerNativeTemplates(), which runs after construction.

registerNativeTemplate() is split in two: the existing method now also claims
the template's class, while registerAuxiliaryTemplate() only adds a type
mapping. xRTComponentTemplate uses the latter, since it registers a second
instance of its own class for RTMultiMethodTemplate and must not displace the
template registered for its own structure.

No behaviour change yet: the INSTANCE statics are still in place and still
what the runtime reads.
Every native template published itself into a process-global mutable static
from inside its own constructor - 139 `INSTANCE = this` sites, none of the
fields volatile or final. Two things were wrong with that. The write is a
this-escape: a partially constructed template becomes reachable JVM-wide
before its constructor finishes. And the field is last-writer-wins across
containers: templates are built per container, a single JVM can build several
(the interpreter connector does), so the container built last silently took
the statics over from every container built before it.

That is broken in series, with no concurrency at all. Build container A, then
container B, and code acting for A reads B's template - and through it B's
container, pool and type system. ProxyComposition.getTemplate() returned
Proxy.INSTANCE, so a composition belonging to A handed out a template owned by
B; xRTFunction.makeAsyncNativeHandle pulled the container for an async call
straight out of the static.

All 139 assignments and all 143 declarations are gone. Lookups now go through
the container's NativeTemplates table, reached from whatever the call site
already has - a container, a frame, a template, or a composition. Where a
static factory had no owner to work from, it takes one: xInt64/xChar/xUInt8/
xNibble/xString.makeHandle, xRTFunction.makeAsyncNativeHandle and the
xRTSignature ensure* cluster all now take the container or frame that is
asking. FullyBoundHandle.NO_OP, which needed a container to construct but is
only ever compared by identity, becomes a lazily created singleton.

`this == INSTANCE` becomes isNativeInstance(X.class). The class has to be
named: the test that matters is whether this template is the native instance
of the class that *declares* the method, and xVar extends xRef, so keying on
getClass() would let a subclass pass a gate that was written for its base.

INCEPTION_CLASS in Identity/xRef/xVar/xTuple/xService was the same defect in
the same constructors - a NativeRebaseConstant over the container's own
ClassConstant, kept in a static. It is now a final instance field. For xRef
and xVar the getter asks the container for its own Ref/Var template rather
than reading `this`, because the templates that inherit it - xFuture, xLazy,
xAtomic, xInject - are not themselves rebased and never populate the field;
reading `this` there produced a null inception constant and broke handle
creation well outside the unit tests.

Behaviour is unchanged for a single container, including one oddity worth
naming: xRTDelegate registers a nibble delegate twice, and INSTANCE therefore
held the second instance while the type registry held the first. The table
keeps last-writer-wins so that stays true.

NativeTemplatePerContainerTest builds two containers in series and asserts
that a composition resolves the template of its own container. On master:

  container A (built first) must resolve its own Proxy template, but got one
  owned by a different container ==> expected: NativeContainer@57167ccb
  but was: NativeContainer@37753b69

The same assertion passes for container B, which is the last-writer-wins
signature: whoever was built last is the only one the statics still serve.
With lookups going through the container's table, the flag no longer selects
anything in 137 of the 143 native templates - the `if (fInstance)` blocks it
guarded are gone. It cannot be removed, though, and it is worth saying why
before someone tries.

Six templates still read it, and xService is the one that settles it. It is
constructed both as the rebased base template over the `Service` interface and
as the template for every concrete service class, and only the first may build
a NativeRebaseConstant - that constructor asserts its argument is an interface.
Dropping the guard fails during bootstrap:

  java.lang.AssertionError
    at org.xvm.asm.constants.NativeRebaseConstant.<init>(NativeRebaseConstant.java:28)
    at org.xvm.runtime.template.xService.<init>(xService.java:58)
    at org.xvm.runtime.template._native.crypto.xRTKeyStore.<init>(xRTKeyStore.java:86)

The table cannot answer the question either, and that is by design: it is
populated after a template finishes constructing, which is exactly the property
that stopped templates escaping mid-construction. So the flag stays, uniformly -
the reflective loader in NativeContainer looks up one constructor shape for
every scanned template, and Container.getTemplate(IdentityConstant) passes it
explicitly for the seven formats it instantiates.
The migration left its call sites spelling out the path to the owner. Asking
for one template read:

    frame.f_context.f_container.nativeTemplates().get(xListMap.class)

Four hops to name one template, at the exact sites this change exists to make
clear about ownership. Two field hops go through another object's internals,
and the two-step table lookup was ceremony: of the native-template lookups on
this branch, all but one were a single get.

Frame gains container(), and Container and Frame gain a nativeTemplate(clz)
shorthand, so the same site reads frame.nativeTemplate(xListMap.class).

That makes three ways to reach the table, which is one too many to leave
undocumented, so each now says which to reach for:

  - NativeTemplates.of(container|frame|template|composition) is the adapter.
    Its job is normalising the four inputs, and ClassTemplate and
    TypeComposition are the reason it has to exist - they have no lookup
    method of their own and are not meant to grow one. 24 call sites: 21
    through a composition, 2 through a template, 1 through a container.
  - Container.nativeTemplates() is the table itself, for work that is not a
    single lookup - registering a template is the one such caller.
  - Container.nativeTemplate(clz) / Frame.nativeTemplate(clz) is the shorthand
    for the common case.

ClassTemplate deliberately does not get a nativeTemplate() of its own;
isNativeInstance() goes through f_container instead, which also names the
owner at the point the identity comparison is made.

Only the sites this branch already touches are converted. On lines this branch
introduces, X.f_context.f_container goes 89 -> 1 (the survivor is a
ServiceHandle, which has no container()), and nativeTemplates().get(...) goes
256 -> 1 (the survivor is Container.nativeTemplate's own body). The ~200
pre-existing f_context.f_container sites elsewhere in the runtime are left
alone; converting those is a separate mechanical change.

No behaviour change: the end-to-end run is still byte-identical to master.
The name was a fossil. "fInstance" meant "assign yourself to INSTANCE", and
that mechanism is gone. What survives reads absurd - 151 parameter
declarations, one `if`, six real users - so a reader's first conclusion is
that it is dead and should be deleted. A commit message arguing otherwise is a
worse fix than a name that does not raise the question.

fBaseTemplate over fRebase, which was the other candidate: five of the six
users spend the flag on a rebased inception identity, so fRebase describes
them well, but the sixth does not. xRTConnector passes false to its super and
uses its own flag to build a process-wide user-agent string - nothing there is
rebased. Across all 151 declarations the flag means one thing, "this is the
container's base template for this class", and five consumers happen to turn
that into a rebase. fBaseTemplate is true at every site; fRebase would be a
fossil of a different kind at the sixth.

The reasoning for keeping the flag at all, which has not changed:

It cannot be removed. xService is constructed both as the rebased base
template over the `Service` interface and as the template for every concrete
service class, and only the first may build a NativeRebaseConstant - that
constructor asserts its argument is an interface. Dropping the guard fails
during bootstrap:

  java.lang.AssertionError
    at org.xvm.asm.constants.NativeRebaseConstant.<init>(NativeRebaseConstant.java:28)
    at org.xvm.runtime.template.xService.<init>(xService.java:58)
    at org.xvm.runtime.template._native.crypto.xRTKeyStore.<init>(xRTKeyStore.java:86)

The native template table cannot answer the question either, and that is by
design: it is populated after a template finishes constructing, which is
exactly the property that stopped templates escaping mid-construction.

Nor can the parameter be dropped from the 137 templates that ignore it. The
reflective loader in NativeContainer looks up one constructor shape for every
scanned template, and Container.getTemplate(IdentityConstant) passes it
explicitly for the seven formats it instantiates. That uniformity is
load-bearing.

Pure rename; no behaviour change.
getPropertyCapacity divides an array length by two, so the (long) cast
in front of it is inert: the result is smaller than the int it started
from, and it widens on the way into makeHandle(Frame, long) regardless.

The two sibling delegates need theirs. LongBasedDelegate multiplies the
length by up to 64 and BitBasedDelegate shifts it left by three, and both
overflow as int. Sitting next to two casts that are load-bearing is
presumably how this one survived.
@lagergren
lagergren marked this pull request as ready for review September 1, 2026 14:07
@lagergren
lagergren requested review from cpurdy and ggleyzer September 1, 2026 14:08
@lagergren

Copy link
Copy Markdown
Contributor Author

Made a mistake. Will reopen later.

@lagergren lagergren closed this Sep 1, 2026
@lagergren lagergren reopened this Sep 1, 2026
@ggleyzer

ggleyzer commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

I completely disagree with the analysis. The "fInstance" argument is passed as "true" only during the native container initialization, which is by design a single-threaded action.

There is a good point regarding Proxy.makeAsyncNativeHandle(). I'll take it under consideration.

My review decision: close; do not apply

@ggleyzer

ggleyzer commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

I reviewed Proxy.makeAsyncNativeHandle() use and I think the code is correct. This method is only called for native methods, which means the methods that belong to types in the native type system. Therefore using the native container is the correct choice - it's used to create/cache class compositions that belong there

…iner

The tests here built two native containers in series to show that the
earlier one still resolved its own templates. That argued the weaker of
this change's two cases: it depends on a container topology that is
disputed, and if that topology is ruled out the defect it demonstrates is
unreachable - so the test could never settle anything. It also gated on
compiled XDK output, which in the javatools build is downstream, so both
of those tests silently assume-skipped and had never run.

What this change actually establishes needs no container at all: a
template is reached through its container's table rather than a
JVM-global field. That is a property of the compiled classes, so read
them. A reviewer of 139 mechanical sites can read a handful, recognise
the pattern, and have the scan guarantee the other 135 did not deviate -
including a site that kept the old shape under a different spelling,
which reading source text would miss.

Two tests remain, both of which run everywhere with no assumption:
- the template tree declares no INSTANCE static and no code in it reads
  one, across all 318 compiled template classes
- componentNameOf maps template classes to the component names the table
  resolves through

Red on master, where the scan reports the INSTANCE statics it finds;
green here. The javadoc names the statics this change does NOT touch
(xPackage.LIST_MAP_TEMPLATE, xEnum.RANGE_TEMPLATE, xArray.MUTABILITY and
others) so the test pins what was done rather than failing on work
nobody claimed to have finished.

NativeTemplates.resolvedNames() is the reason the table was wanted:
asking which templates a container has pulled in is one call where it was
previously not expressible at all. Its test needs a live container, so it
belongs in the xdk build where the distribution exists.
@lagergren

Copy link
Copy Markdown
Contributor Author

Rewrote the tests in this PR after re-reading them. Two problems with what was there, both mine.

They argued the weaker of this change's two cases. The tests built two native containers in series and checked the first still resolved its own templates. That depends on a container topology you've said goes against the design assumptions — and if that topology is ruled out, the defect those tests demonstrate is unreachable, so they could never settle anything. Meanwhile the case that needs no such agreement went untested: 139 copy-pasted mutable statics became one container-owned table.

And they never ran. They gated on compiled XDK output via assumeTrue, but in the javatools build the XDK is downstream — javatools is what produces the compiler that builds it. So they silently skipped and CI stayed green. That was true of the whole batch; I found it only when I forced one to execute, and it failed immediately on a real bug (FileStructure.merge asserting when a module already present is merged — it supersedes a fingerprint but not a real module). Ten days behind a green suite.

What's here now is two tests, both reading javatools' compiled classes — not XTC modules, nothing from the XDK. compileJava is upstream of test, so the inputs exist by construction and there is nothing to assume:

  • theTemplateTreeIsFreeOfInstanceStatics — across all 318 compiled template classes, none declares an INSTANCE static and no code in the tree reads one. Red on master, green here.
  • componentNamesFollowTheLoadersRule — the class-to-component-name mapping the table resolves through.

The reason for reading class files rather than source is your review time. You read four of the 139 sites, recognise the pattern, and the scan guarantees the other 135 didn't deviate — including a site that kept the old shape under a different spelling, which text-matching would miss. It turns a wide, shallow diff from a reading task into one file plus a proof.

Two things I want to be straight about.

The scan keys on the name INSTANCE. The stronger invariant — no static field of a ClassTemplate type anywhere in the tree — is what I tried first, and it fails: xPackage.LIST_MAP_TEMPLATE, xEnum.RANGE_TEMPLATE, xRTComponentTemplate.MULTI_METHOD_TEMPLATE, xArray.MUTABILITY and about six more are the same pattern under other names. They're worth converting and they are not in this PR. The javadoc names them so the test pins what was done rather than failing on work nobody claimed. I'd rather you hear that from me than find it yourself and read the PR as incomplete.

NativeTemplates.resolvedNames() has no test here. It's the capability the table was wanted for — asking which templates a container has actually pulled in is one call, where against 139 statics it wasn't expressible at all. Exercising it needs a live container, so that test belongs in the xdk build where the distribution exists, not here.

Trimming this class to what runs without a container took
ownerlessFactoryPathsStillWork with it. That was wrong: it pins two NPEs
this PR introduced and then fixed - xRTType.makeForeignHandle, whose
callers pass no frame, and Utils.translate, which turns a native
throwable into a handle with a null frame. Both paths used to read a
process-global template and so never needed an owner; they now take one
from the composition they build against, and nothing else covers them.

It builds one container and says nothing about how many a host may
create, so it does not carry the topology claim the two deleted tests
did.

Its repository helper is not read-through: with it on, LinkedRepository
answers a hit in a later repository by cloning the module into the first,
and the first here is a read-only build output directory - the store
fails and a module that was found comes back null. The checkout walk also
accepts ".git" as a file, which is what it is in a linked worktree.

3 tests, 0 skipped.
@lagergren

Copy link
Copy Markdown
Contributor Author

Correcting myself on two points in the comment above.

"They never ran" was wrong. I checked by restoring the old test file and running it in this branch's tree: all four tests ran, none skipped. What is true is narrower — they gate on assumeTrue(systemModulesAvailable()), so whether they run depends on build state rather than on anything the test controls. They do skip in some trees (a linked worktree, where the checkout walk looked for .git as a directory when it is a file there), and CI runs distZip check, so I cannot tell you from the outside whether :javatools:test fires before or after lib_ecstasy's output exists. "Can silently skip depending on build state" is what I should have written; "never ran" overstated it.

I removed a test I should have kept. Trimming the class to what runs without a container also removed ownerlessFactoryPathsStillWork, which pins the two NPEs this PR introduced and then fixed - xRTType.makeForeignHandle and Utils.translate, both of which build a handle with no frame to take an owner from. Nothing else covers those paths. It is restored in b00ce2e, so the class is now three tests: the two class-file scans plus that one, which builds a single container and makes no claim about how many a host may create.

Its repository helper is also no longer read-through. With read-through on, LinkedRepository answers a hit in a later repository by cloning the module into the first one - and the first here is a read-only build output directory, so the store fails and a module that was found comes back as null. That is worth knowing independently of this PR; it is a live way for a lookup to report "not found" for something it located.

The description is updated for the commit count, the diffstat and the test numbers, which had all gone stale.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants